|
| 1 | +#![deny(rust_2018_idioms)] |
| 2 | +#![deny(clippy::all)] |
| 3 | + |
| 4 | +use adex_primitives::market_channel::{MarketChannel, MarketStatusType}; |
| 5 | +use adex_primitives::{AdUnit, BigNum, SpecValidators, TargetingTag}; |
| 6 | +use chrono::Utc; |
| 7 | +use serde::{Deserialize, Serialize}; |
| 8 | + |
| 9 | +pub type TargetingScore = f64; |
| 10 | +pub type MinTargetingScore = TargetingScore; |
| 11 | + |
| 12 | +const IPFS_GATEWAY: &str = "https://ipfs.moonicorn.network/ipfs/"; |
| 13 | + |
| 14 | +#[derive(Serialize, Deserialize)] |
| 15 | +#[serde(rename_all = "camelCase")] |
| 16 | +pub struct AdViewManagerOptions { |
| 17 | + // Defaulted via defaultOpts |
| 18 | + #[serde(rename = "marketURL")] |
| 19 | + pub market_url: String, |
| 20 | + /// Defaulted |
| 21 | + pub accepted_states: Vec<MarketStatusType>, |
| 22 | + /// Defaulted |
| 23 | + pub min_per_impression: BigNum, |
| 24 | + /// Defaulted |
| 25 | + pub min_targeting_score: MinTargetingScore, |
| 26 | + /// Defaulted |
| 27 | + pub randomize: bool, |
| 28 | + pub publisher_addr: String, |
| 29 | + pub whitelisted_token: String, |
| 30 | + pub whitelisted_type: Option<String>, |
| 31 | + /// Defaulted |
| 32 | + pub top_by_price: usize, |
| 33 | + /// Defaulted |
| 34 | + pub top_by_score: usize, |
| 35 | + #[serde(default)] |
| 36 | + pub targeting: Vec<TargetingTag>, |
| 37 | + pub width: Option<u64>, |
| 38 | + pub height: Option<u64>, |
| 39 | + pub fallback_unit: Option<String>, |
| 40 | + /// Defaulted |
| 41 | + pub disabled_video: bool, |
| 42 | +} |
| 43 | + |
| 44 | +impl AdViewManagerOptions { |
| 45 | + pub fn size(&self) -> Option<(u64, u64)> { |
| 46 | + self.width |
| 47 | + .and_then(|width| self.height.and_then(|height| Some((width, height)))) |
| 48 | + } |
| 49 | +} |
| 50 | + |
| 51 | +#[derive(Debug)] |
| 52 | +pub struct UnitByPrice { |
| 53 | + pub unit: AdUnit, |
| 54 | + pub channel_id: String, |
| 55 | + pub validators: SpecValidators, |
| 56 | + pub min_targeting_score: MinTargetingScore, |
| 57 | + pub min_per_impression: BigNum, |
| 58 | +} |
| 59 | + |
| 60 | +#[derive(Debug)] |
| 61 | +pub struct Unit { |
| 62 | + pub unit: AdUnit, |
| 63 | + pub channel_id: String, |
| 64 | + pub validators: SpecValidators, |
| 65 | + pub min_targeting_score: MinTargetingScore, |
| 66 | + pub min_per_impression: BigNum, |
| 67 | + pub targeting_score: TargetingScore, |
| 68 | +} |
| 69 | + |
| 70 | +impl Unit { |
| 71 | + pub fn new(by_price: UnitByPrice, targeting_score: TargetingScore) -> Self { |
| 72 | + Self { |
| 73 | + unit: by_price.unit, |
| 74 | + channel_id: by_price.channel_id, |
| 75 | + validators: by_price.validators, |
| 76 | + min_targeting_score: by_price.min_targeting_score, |
| 77 | + min_per_impression: by_price.min_per_impression, |
| 78 | + targeting_score, |
| 79 | + } |
| 80 | + } |
| 81 | +} |
| 82 | + |
| 83 | +pub fn apply_selection(campaigns: &[MarketChannel], options: &AdViewManagerOptions) -> Vec<Unit> { |
| 84 | + let eligible = campaigns.iter().filter(|campaign| { |
| 85 | + options |
| 86 | + .accepted_states |
| 87 | + .contains(&campaign.status.status_type) |
| 88 | + && campaign |
| 89 | + .spec |
| 90 | + .active_from |
| 91 | + .map(|datetime| datetime < Utc::now()) |
| 92 | + .unwrap_or(true) |
| 93 | + && campaign.deposit_asset == options.whitelisted_token |
| 94 | + && campaign.spec.min_per_impression >= options.min_per_impression |
| 95 | + }); |
| 96 | + |
| 97 | + let mut units: Vec<UnitByPrice> = eligible |
| 98 | + .flat_map(|campaign| { |
| 99 | + let mut units = vec![]; |
| 100 | + for ad_unit in campaign.spec.ad_units.iter() { |
| 101 | + let unit = UnitByPrice { |
| 102 | + unit: ad_unit.clone(), |
| 103 | + channel_id: campaign.id.clone(), |
| 104 | + validators: campaign.spec.validators.clone(), |
| 105 | + min_targeting_score: ad_unit |
| 106 | + .min_targeting_score |
| 107 | + .or(campaign.spec.min_targeting_score) |
| 108 | + .unwrap_or_else(|| 0.into()), |
| 109 | + min_per_impression: campaign.spec.min_per_impression.clone(), |
| 110 | + }; |
| 111 | + |
| 112 | + units.push(unit); |
| 113 | + } |
| 114 | + |
| 115 | + units |
| 116 | + }) |
| 117 | + .collect(); |
| 118 | + |
| 119 | + // Sort |
| 120 | + units.sort_by(|b, a| a.min_per_impression.cmp(&b.min_per_impression)); |
| 121 | + units.truncate(options.top_by_price); |
| 122 | + |
| 123 | + let units = units.into_iter().filter(|unit| { |
| 124 | + options |
| 125 | + .whitelisted_type |
| 126 | + .as_ref() |
| 127 | + .map(|whitelisted_type| { |
| 128 | + whitelisted_type != &unit.unit.ad_type |
| 129 | + && !(options.disabled_video && is_video(&unit.unit)) |
| 130 | + }) |
| 131 | + .unwrap_or(false) |
| 132 | + }); |
| 133 | + |
| 134 | + let mut by_score: Vec<Unit> = units |
| 135 | + .collect::<Vec<UnitByPrice>>() |
| 136 | + .into_iter() |
| 137 | + .filter_map(|by_price| { |
| 138 | + let targeting_score = |
| 139 | + calculate_target_score(&by_price.unit.targeting, &options.targeting); |
| 140 | + if targeting_score >= options.min_targeting_score |
| 141 | + && targeting_score >= by_price.min_targeting_score |
| 142 | + { |
| 143 | + Some(Unit::new(by_price, targeting_score)) |
| 144 | + } else { |
| 145 | + None |
| 146 | + } |
| 147 | + }) |
| 148 | + .collect(); |
| 149 | + by_score.sort_by(|a, b| { |
| 150 | + a.targeting_score |
| 151 | + .partial_cmp(&b.targeting_score) |
| 152 | + .expect("Should always be comparable") |
| 153 | + }); |
| 154 | + by_score.truncate(options.top_by_score); |
| 155 | + |
| 156 | + by_score |
| 157 | +} |
| 158 | + |
| 159 | +fn is_video(ad_unit: &AdUnit) -> bool { |
| 160 | + ad_unit.media_mime.split('/').nth(0) == Some("video") |
| 161 | +} |
| 162 | + |
| 163 | +fn calculate_target_score(a: &[TargetingTag], b: &[TargetingTag]) -> TargetingScore { |
| 164 | + a.iter() |
| 165 | + .map(|x| -> TargetingScore { |
| 166 | + match b.iter().find(|y| y.tag == x.tag) { |
| 167 | + Some(b) => (&x.score * &b.score).into(), |
| 168 | + None => 0.into(), |
| 169 | + } |
| 170 | + }) |
| 171 | + .sum() |
| 172 | +} |
| 173 | + |
| 174 | +#[derive(Serialize)] |
| 175 | +#[serde(rename_all = "camelCase")] |
| 176 | +struct Event { |
| 177 | + #[serde(rename = "type")] |
| 178 | + event_type: String, |
| 179 | + publisher: String, |
| 180 | + ad_unit: String, |
| 181 | +} |
| 182 | + |
| 183 | +#[derive(Serialize)] |
| 184 | +struct EventBody { |
| 185 | + events: Vec<Event>, |
| 186 | +} |
| 187 | + |
| 188 | +fn image_html( |
| 189 | + event_body: &str, |
| 190 | + on_load: &str, |
| 191 | + size: &Option<(u64, u64)>, |
| 192 | + image_url: &str, |
| 193 | +) -> String { |
| 194 | + let size = size |
| 195 | + .map(|(width, height)| format!("width=\"{}\" height=\"{}\"", width, height)) |
| 196 | + .unwrap_or_else(|| "".to_string()); |
| 197 | + |
| 198 | + format!("<img src=\"{image_url}\" data-event-body='{event_body}' alt=\"AdEx ad\" rel=\"nofollow\" onload=\"{on_load}\" {size}>", |
| 199 | + image_url = image_url, event_body = event_body, on_load = on_load, size = size) |
| 200 | +} |
| 201 | + |
| 202 | +fn normalize_url(url: &str) -> String { |
| 203 | + if url.starts_with("ipfs://") { |
| 204 | + url.replacen("ipfs://", IPFS_GATEWAY, 1) |
| 205 | + } else { |
| 206 | + url.to_string() |
| 207 | + } |
| 208 | +} |
| 209 | + |
| 210 | +fn video_html( |
| 211 | + event_body: &str, |
| 212 | + on_load: &str, |
| 213 | + size: &Option<(u64, u64)>, |
| 214 | + image_url: &str, |
| 215 | + media_mime: &str, |
| 216 | +) -> String { |
| 217 | + let size = size |
| 218 | + .map(|(width, height)| format!("width=\"{}\" height=\"{}\"", width, height)) |
| 219 | + .unwrap_or_else(|| "".to_string()); |
| 220 | + |
| 221 | + format!("<video {size} loop autoplay data-event-body='{event_body}' onloadeddata=\"${on_load}\" muted> |
| 222 | + <source src=\"{image_url}\" type=\"{media_mime}\"> |
| 223 | + </video>", size = size, event_body = event_body, on_load = on_load, image_url = image_url, media_mime = media_mime) |
| 224 | +} |
| 225 | + |
| 226 | +pub fn get_html( |
| 227 | + options: &AdViewManagerOptions, |
| 228 | + ad_unit: AdUnit, |
| 229 | + channel_id: &str, |
| 230 | + validators: &SpecValidators, |
| 231 | +) -> String { |
| 232 | + let ev_body = EventBody { |
| 233 | + events: vec![Event { |
| 234 | + event_type: "IMPRESSION".into(), |
| 235 | + publisher: options.publisher_addr.clone(), |
| 236 | + ad_unit: ad_unit.ipfs.clone(), |
| 237 | + }], |
| 238 | + }; |
| 239 | + |
| 240 | + let on_load: String = validators.into_iter().map(|validator| { |
| 241 | + let fetch_opts = "{ method: 'POST', headers: { 'content-type': 'application/json' }, body: this.dataset.eventBody }"; |
| 242 | + let fetch_url = format!("{}/channel/{}/events", validator.url, channel_id); |
| 243 | + |
| 244 | + format!("fetch({}, {});", fetch_url, fetch_opts) |
| 245 | + }).collect(); |
| 246 | + |
| 247 | + let ev_body = serde_json::to_string(&ev_body).expect("should convert"); |
| 248 | + |
| 249 | + get_unit_html(&options.size(), ad_unit, &ev_body, &on_load) |
| 250 | +} |
| 251 | + |
| 252 | +fn get_unit_html( |
| 253 | + size: &Option<(u64, u64)>, |
| 254 | + ad_unit: AdUnit, |
| 255 | + event_body: &str, |
| 256 | + on_load: &str, |
| 257 | +) -> String { |
| 258 | + let image_url = normalize_url(&ad_unit.media_url); |
| 259 | + |
| 260 | + let element_html = if is_video(&ad_unit) { |
| 261 | + video_html(event_body, on_load, size, &image_url, &ad_unit.media_mime) |
| 262 | + } else { |
| 263 | + image_html(event_body, on_load, size, &image_url) |
| 264 | + }; |
| 265 | + |
| 266 | + let style_size = size |
| 267 | + .map(|(width, height)| format!("width: {}; height: {};", width, height)) |
| 268 | + .unwrap_or_else(|| "".to_string()); |
| 269 | + |
| 270 | + let adex_icon = "<a href=\"https://www.adex.network\" target=\"_blank\" rel=\"noopener noreferrer\" |
| 271 | + style=\"position: absolute; top: 0; right: 0;\" |
| 272 | + > |
| 273 | + <svg version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" x=\"0px\" y=\"0px\" width=\"18px\" |
| 274 | + height=\"18px\" viewBox=\"0 0 18 18\" style=\"enable-background:new 0 0 18 18;\" xml:space=\"preserve\"> |
| 275 | + <style type=\"text/css\"> |
| 276 | + .st0{fill:#FFFFFF;} |
| 277 | + .st1{fill:#1B75BC;} |
| 278 | + </style> |
| 279 | + <defs> |
| 280 | + </defs> |
| 281 | + <rect class=\"st0\" width=\"18\" height=\"18\"/> |
| 282 | + <path class=\"st1\" d=\"M14,12.1L10.9,9L14,5.9L12.1,4L9,7.1L5.9,4L4,5.9L7.1,9L4,12.1L5.9,14L9,10.9l3.1,3.1L14,12.1z M7.9,2L6.4,3.5 |
| 283 | + L7.9,5L9,3.9L10.1,5l1.5-1.5L10,1.9l-1-1L7.9,2 M7.9,16l-1.5-1.5L7.9,13L9,14.1l1.1-1.1l1.5,1.5L10,16.1l-1,1L7.9,16\"/> |
| 284 | + </svg> |
| 285 | + </a>"; |
| 286 | + |
| 287 | + let result = format!(" |
| 288 | + <div style=\"position: relative; overflow: hidden; {size}\"> |
| 289 | + <a href=\"{target_url}\" target=\"_blank\" rel=\"noopener noreferrer\">{element_html}</a> |
| 290 | + {adex_icon} |
| 291 | + </div> |
| 292 | + ", target_url = ad_unit.target_url, size = style_size, element_html = element_html, adex_icon = adex_icon); |
| 293 | + |
| 294 | + result.to_string() |
| 295 | +} |
| 296 | + |
| 297 | +#[cfg(test)] |
| 298 | +mod test { |
| 299 | + use super::*; |
| 300 | + |
| 301 | + fn get_ad_unit(media_mime: &str) -> AdUnit { |
| 302 | + AdUnit { |
| 303 | + ipfs: "".to_string(), |
| 304 | + ad_type: "".to_string(), |
| 305 | + media_url: "".to_string(), |
| 306 | + media_mime: media_mime.to_string(), |
| 307 | + target_url: "".to_string(), |
| 308 | + targeting: vec![], |
| 309 | + min_targeting_score: None, |
| 310 | + tags: vec![], |
| 311 | + owner: "".to_string(), |
| 312 | + created: Utc::now(), |
| 313 | + title: None, |
| 314 | + description: None, |
| 315 | + archived: false, |
| 316 | + modified: None, |
| 317 | + } |
| 318 | + } |
| 319 | + |
| 320 | + #[test] |
| 321 | + fn test_is_video() { |
| 322 | + assert_eq!(true, is_video(&get_ad_unit("video/avi"))); |
| 323 | + assert_eq!(false, is_video(&get_ad_unit("image/jpeg"))); |
| 324 | + } |
| 325 | + |
| 326 | + #[test] |
| 327 | + fn normalization_of_url() { |
| 328 | + // IPFS case |
| 329 | + assert_eq!(format!("{}123", IPFS_GATEWAY), normalize_url("ipfs://123")); |
| 330 | + assert_eq!( |
| 331 | + format!("{}123ipfs://", IPFS_GATEWAY), |
| 332 | + normalize_url("ipfs://123ipfs://") |
| 333 | + ); |
| 334 | + |
| 335 | + // Non-IPFS case |
| 336 | + assert_eq!("http://123".to_string(), normalize_url("http://123")); |
| 337 | + } |
| 338 | +} |
0 commit comments